Conversation
A message reader that met a non-object token returned an empty message
without consuming the token. Inside a repeated field the enclosing
array loop then saw the same token forever and appended messages until
OutOfMemoryError: {"repeatedMsg":[1]} or [true] on all three decode
paths, {"repeatedMsg":[null]} on the codegen path, repeated Struct and
Empty elements, and non-array values for repeated message fields.
Every reader now consumes the container it expects or throws a
JSONException naming the proto type: '{' for messages, maps, Struct,
Any and Empty, '[' for repeated fields and ListValue. The checks live
in FieldReader.requireObjectStart/requireArrayStart, which generated
decoders call too, so all paths report the same error. Valid proto3
JSON always has these shapes, so no valid input changes behavior.
Codegen also no longer accepts a bare number or array as
google.protobuf.Empty, matching the runtime paths.
BuffJsonMalformedContainerTest runs 209 malformed-container cases on
all three paths under a timeout. Before the fix the test JVM died with
"Java heap space".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
b2e4ee6 to
f56e58e
Compare
Performance comparisonWorkflow and raw JMH artifacts Java 21 performanceBase: ad10fe5 → candidate: b0e72df Throughput alerts are advisory. Existing allocation budgets are enforced separately.
Java 25 performanceBase: ad10fe5 → candidate: b0e72df Throughput alerts are advisory. Existing allocation budgets are enforced separately.
|
… checks Follow-ups from review of the infinite-loop fix: - Codegen rejects null repeated elements with a JSONException (FieldReader.requireNonNullElement), as JsonFormat does. They used to throw NullPointerException (Timestamp, Duration, FieldMask, String/BytesValue) or add a phantom default element (int64, bool, enum, wrappers). Value elements keep null-as-NullValue, and NullValue elements now map null to NULL_VALUE. - The typed and reflection repeated readers keep null elements of repeated Value/NullValue as values (FieldReader.nullValueFor) instead of dropping them, matching codegen, JsonFormat and the encoder's output. Other null elements are still skipped on those paths. - Empty or whitespace-only input decodes to null from every overload (byte[], slice, InputStream), like an empty String. The new object-start check would otherwise have turned these into exceptions. - Hoist the descriptor in readMessage(JSONReader, Builder), use the FIELD_READER constant for every generated FieldReader reference, and correct the docs. Adds TestRepeatedNullValue to the test protos so the generated NullValue branch is compiled and tested on all three paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
A null map value is a value for map<K, google.protobuf.Value> (a wrapped NullValue) and map<K, google.protobuf.NullValue> (NULL_VALUE), as in JsonFormat and as the encoder writes them. Codegen dropped such entries, and the runtime paths stored an empty Value (no kind set), so the paths disagreed and an encode/decode round trip lost data. - Generated map loops no longer skip null for Value maps (readJsonValue returns NullValue itself) and emit putXValue(key, 0) for NullValue maps. Other value types keep the existing null handling. - FieldReader.readMap and the typed map parser use nullValueFor for Value maps. - WellKnownTypes.NULL_JSON_VALUE is now the single shared NullValue Value, used by readJsonValue, packed Any and nullValueFor. - Javadoc: BuffJsonGeneratedDecoder.readMessage must consume or throw (regenerate decoders from older plugins), and BuffJsonDecoder returns null for empty input. Adds TestNullMapValues to the test protos, with a test that checks JsonFormat parity and the encoder round trip on all three paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
Problem
A tiny untrusted payload can make decoding loop until
OutOfMemoryError:When a message reader met a non-object token, it returned an empty message without consuming the token. It ignored
nextIfObjectStart()'s result, andreadFieldName()returnsnullon a non-name token, which ended the field loop. Inside a repeated field, the enclosingwhile (!reader.nextIfArrayEnd())loop then saw the same token forever, appending messages until the heap was exhausted.Inputs that looped before this change (
-Xmx96m, 8 s timeout):{"repeatedNested":[1]},[true],[[]],[{..},2]{"repeatedNested":[null]}{"repeatedNested":5},{"repeatedNested":{}}(not an array){"repeatedStruct":[1]},[[1]]{"repeatedStruct":[null]}{"repeatedEmpty":[1]},[[]]Singular and map values were rejected only by accident: the leftover token made the top-level "input not end" check fail. The official conformance cases for null repeated elements appeared to pass only because
ConformanceTesteecatchesThrowable, which reported theOutOfMemoryErroras a parse failure.Fix
1. Every reader consumes the container it expects, or throws a
JSONExceptionthat names the proto type:{for messages, maps,Struct,AnyandEmpty;[for repeated fields andListValue.The checks live in two helpers,
FieldReader.requireObjectStartandrequireArrayStart, and the generated decoders call them too. So all three paths produce the same error, for exampleExpected a JSON object for message io.suboptimal.buffjson.proto.NestedMessage, offset 32, …. Changed code:DecoderGenerator— message, repeated, map and inlineEmptyreadsProtobufMessageReader,TypedMessageReaderSchema,FieldReader.readRepeated/readMapWellKnownTypes.readStruct/readListValue/readAnyEvery element read now consumes at least its opening token, so each iteration of an array loop makes progress. Termination no longer depends on what the element reader does. The checks test a
booleanthe reader already returned, so there is no cost on the success path. TheBuffJsonGeneratedDecoder.readMessageJavadoc now requires this consume-or-throw behavior.2. Null elements in repeated fields, found by the same test matrix:
nullelement with aJSONException(FieldReader.requireNonNullElement), asJsonFormatdoes. Before, it threwNullPointerException(Timestamp, Duration, FieldMask, String/BytesValue) or added a phantom default element (int64, bool, enum, wrappers).google.protobuf.Value/NullValueelements keepnullas a value on every path (a wrappedNullValue, orNULL_VALUE). The typed and reflection paths used to drop them, so{"repeatedValue":[null,1]}lost an element, even though that is exactly what the encoder writes.3. Null map values of
Value/NullValuetype are kept on every path (FieldReader.nullValueFor), asJsonFormatand the encoder expect. Before, codegen dropped the entry, and the runtime paths stored an emptyValuewith no kind set, so a round trip lost data. A single sharedWellKnownTypes.NULL_JSON_VALUEnow serves every reader.4. Empty input. Empty or whitespace-only input decodes to
nullfrom every overload (byte[], slice,InputStream), like an emptyString. This is now documented onBuffJsonDecoder. Without it, the new object-start check would have turned these inputs into exceptions.Behavior changes (invalid, empty or null input only)
JSONExceptioninstead of looping or failing on the trailing-input check. Valid proto3 JSON always uses these shapes, andJsonFormatrejects everything else.Empty: a bare number or array forgoogle.protobuf.Emptyis now rejected, matching the runtime paths.Value/NullValueelements and map values are kept as values on every path (see 2 and 3).byte[]/InputStreamor whitespace-only input now returnsnull. Onmainit returned an empty message; an emptyStringalready returnednull.Tests
New
BuffJsonMalformedContainerTest: each case runs on all three decode paths insideassertTimeoutPreemptively. It covers:repeated Value,repeated NullValue,map<_, Value>andmap<_, NullValue>nulls, checked againstJsonFormatplus an encoder round trip;Against the unfixed code, the forked test JVM died with
Java heap space.New test messages:
TestRepeatedNullValueandTestNullMapValueswere added to the test protos so the generated null branches are compiled and exercised.mvn -B clean verify: all modules pass (730 tests inbuff-json-tests), including the benchmark and conformance-testee builds.Not run locally: the official
conformance_test_runner. CI runs it for all threeBUFFJSON_PATHs.Consumers must rebuild with
mvn clean installto regenerate decoders. The protobuf Maven plugin only regenerates when.protoinputs change.Not changed (possible follow-ups)
Valuerepeated fields are still skipped by the typed and reflection paths, while codegen rejects them.JsonFormat.runtimeNullMapValuesAndUnknownEnumNumbersMatchReflectionpins the runtime behavior, so this needs a decision on which is intended.[{"a":1, {"a":2}]is still accepted as two elements. The existing truncated-object leniency (BuffJsonErrorTest.truncatedObjectParsesLeniently) is unchanged.readUnsignedLongthrowsNullPointerExceptionfor an object element ({"repeatedUint64":[{}]}).[{}]or[1.5]for an enum,[true]for a double,[1]for aStringValue.U+001Aas end of input.assertTimeoutPreemptivelycan't stop a runaway decode thread. If the fix regresses, the test fails at the timeout, but the fork may still run out of memory afterwards.🤖 Generated with Claude Code
https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY